AWSTemplateFormatVersion: '2010-09-09'
Description: >
  CloudWatch Log Retention Remediator.
  Purpose-specific, opt-in remediation module for setting retention on CloudWatch
  log groups that do not already have a retention policy. This template is separate
  from the cost optimization assessment/reporting template.

Parameters:
  ProjectName:
    Type: String
    Default: cloudwatch-log-retention-remediator
    Description: Base name used for created resources.

  RetentionInDays:
    Type: Number
    Default: 90
    AllowedValues:
      - 1
      - 3
      - 5
      - 7
      - 14
      - 30
      - 60
      - 90
      - 120
      - 150
      - 180
      - 365
      - 400
      - 545
      - 731
      - 1096
      - 1827
      - 2192
      - 2557
      - 2922
      - 3288
      - 3653
    Description: CloudWatch Logs retention period to apply to log groups without retention.

  TargetRegionsCsv:
    Type: String
    Default: all-enabled
    Description: >
      Comma-separated AWS Regions to scan, or all-enabled to scan all enabled Regions
      returned by EC2 DescribeRegions.

  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
    Description: Comma-separated AWS Regions to exclude from scanning/remediation.

  LogGroupPrefix:
    Type: String
    Default: ''
    Description: Optional log group name prefix filter. Leave blank to scan all log groups.

  ExcludedLogGroupPrefixesCsv:
    Type: String
    Default: ''
    Description: >
      Optional comma-separated log group prefixes to skip. Example: /aws/controltower,/aws/organizations

  DryRun:
    Type: String
    Default: 'true'
    AllowedValues:
      - 'true'
      - 'false'
    Description: When true, only report what would be changed. When false, set retention.

  OnlyIfNoRetention:
    Type: String
    Default: 'true'
    AllowedValues:
      - 'true'
      - 'false'
    Description: >
      When true, only set retention on log groups with no retention policy.
      When false, also change existing retention if it differs from RetentionInDays.

  ContinueOnAccessDenied:
    Type: String
    Default: 'true'
    AllowedValues:
      - 'true'
      - 'false'
    Description: Continue scanning other resources when a Region/resource is blocked by IAM/SCP.

  MaxLogGroupsPerRun:
    Type: Number
    Default: 0
    Description: >
      Safety limit. 0 means no limit. If greater than 0, stops after evaluating this many log groups.

  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues:
      - 'true'
      - 'false'
    Description: Whether to create an enabled EventBridge schedule for recurring runs.

  ScheduleExpression:
    Type: String
    Default: rate(7 days)
    Description: EventBridge schedule expression used when ScheduleEnabled is true.

Conditions:
  IsScheduleEnabled: !Equals [!Ref ScheduleEnabled, 'true']

Resources:
  RemediatorRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ProjectName}-${AWS::AccountId}-${AWS::Region}-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: CloudWatchLogRetentionRemediationPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: DiscoverRegions
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: ReadCloudWatchLogGroups
                Effect: Allow
                Action:
                  - logs:DescribeLogGroups
                Resource: '*'
              - Sid: SetCloudWatchLogRetention
                Effect: Allow
                Action:
                  - logs:PutRetentionPolicy
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${ProjectName}'
      Description: Purpose-specific remediation Lambda for CloudWatch Logs retention policies.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 256
      Role: !GetAtt RemediatorRole.Arn
      Environment:
        Variables:
          RETENTION_IN_DAYS: !Ref RetentionInDays
          TARGET_REGIONS_CSV: !Ref TargetRegionsCsv
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
          LOG_GROUP_PREFIX: !Ref LogGroupPrefix
          EXCLUDED_LOG_GROUP_PREFIXES_CSV: !Ref ExcludedLogGroupPrefixesCsv
          DRY_RUN: !Ref DryRun
          ONLY_IF_NO_RETENTION: !Ref OnlyIfNoRetention
          CONTINUE_ON_ACCESS_DENIED: !Ref ContinueOnAccessDenied
          MAX_LOG_GROUPS_PER_RUN: !Ref MaxLogGroupsPerRun
      Code:
        ZipFile: |
          import json
          import os
          import traceback
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError


          def utc_now_iso():
              return datetime.now(timezone.utc).isoformat()


          def env_bool(name, default=False):
              value = os.environ.get(name, str(default)).strip().lower()
              return value in ("1", "true", "yes", "y")


          def env_int(name, default=0):
              try:
                  return int(os.environ.get(name, str(default)).strip())
              except Exception:
                  return default


          def split_csv(value):
              if value is None:
                  return []
              return [item.strip() for item in str(value).split(',') if item.strip()]


          def get_enabled_regions(excluded_regions):
              requested = os.environ.get('TARGET_REGIONS_CSV', 'all-enabled').strip()
              if requested and requested.lower() != 'all-enabled':
                  regions = split_csv(requested)
              else:
                  ec2 = boto3.client('ec2', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
                  response = ec2.describe_regions(AllRegions=False)
                  regions = sorted([r['RegionName'] for r in response.get('Regions', [])])
              return [r for r in regions if r not in excluded_regions]


          def should_skip_log_group(log_group_name, excluded_prefixes):
              for prefix in excluded_prefixes:
                  if log_group_name.startswith(prefix):
                      return True
              return False


          def scan_region(region, settings):
              logs = boto3.client('logs', region_name=region)
              paginator = logs.get_paginator('describe_log_groups')

              kwargs = {}
              if settings['log_group_prefix']:
                  kwargs['logGroupNamePrefix'] = settings['log_group_prefix']

              result = {
                  'region': region,
                  'evaluated': 0,
                  'would_update': 0,
                  'updated': 0,
                  'skipped_existing_retention': 0,
                  'skipped_excluded_prefix': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'updates': []
              }

              for page in paginator.paginate(**kwargs):
                  for lg in page.get('logGroups', []):
                      if settings['max_log_groups_per_run'] and result['evaluated'] >= settings['max_log_groups_per_run']:
                          result['stopped_due_to_max_log_groups_per_run'] = True
                          return result

                      result['evaluated'] += 1
                      name = lg.get('logGroupName')
                      current_retention = lg.get('retentionInDays')

                      if not name:
                          continue

                      if should_skip_log_group(name, settings['excluded_log_group_prefixes']):
                          result['skipped_excluded_prefix'] += 1
                          continue

                      if settings['only_if_no_retention'] and current_retention is not None:
                          result['skipped_existing_retention'] += 1
                          continue

                      if current_retention == settings['retention_in_days']:
                          result['skipped_existing_retention'] += 1
                          continue

                      action_record = {
                          'log_group_name': name,
                          'previous_retention_in_days': current_retention,
                          'target_retention_in_days': settings['retention_in_days']
                      }

                      if settings['dry_run']:
                          result['would_update'] += 1
                          result['updates'].append(action_record)
                          continue

                      try:
                          logs.put_retention_policy(
                              logGroupName=name,
                              retentionInDays=settings['retention_in_days']
                          )
                          result['updated'] += 1
                          result['updates'].append(action_record)
                      except ClientError as e:
                          code = e.response.get('Error', {}).get('Code', 'Unknown')
                          message = e.response.get('Error', {}).get('Message', str(e))
                          failure = {
                              'log_group_name': name,
                              'error_code': code,
                              'error_message': message
                          }
                          if code in ('AccessDeniedException', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                              result['access_denied'] += 1
                          else:
                              result['failed'] += 1
                          result['failures'].append(failure)

                          if not settings['continue_on_access_denied']:
                              raise

              return result


          def handler(event, context):
              settings = {
                  'retention_in_days': env_int('RETENTION_IN_DAYS', 90),
                  'excluded_regions': split_csv(os.environ.get('EXCLUDED_REGIONS_CSV', '')),
                  'log_group_prefix': os.environ.get('LOG_GROUP_PREFIX', '').strip(),
                  'excluded_log_group_prefixes': split_csv(os.environ.get('EXCLUDED_LOG_GROUP_PREFIXES_CSV', '')),
                  'dry_run': env_bool('DRY_RUN', True),
                  'only_if_no_retention': env_bool('ONLY_IF_NO_RETENTION', True),
                  'continue_on_access_denied': env_bool('CONTINUE_ON_ACCESS_DENIED', True),
                  'max_log_groups_per_run': env_int('MAX_LOG_GROUPS_PER_RUN', 0),
              }

              if isinstance(event, dict):
                  if 'dry_run' in event:
                      settings['dry_run'] = bool(event['dry_run'])
                  if 'retention_in_days' in event:
                      settings['retention_in_days'] = int(event['retention_in_days'])
                  if 'target_regions' in event and event['target_regions']:
                      os.environ['TARGET_REGIONS_CSV'] = ','.join(event['target_regions'])
                  if 'excluded_regions' in event and event['excluded_regions']:
                      settings['excluded_regions'] = list(event['excluded_regions'])
                  if 'log_group_prefix' in event:
                      settings['log_group_prefix'] = str(event['log_group_prefix'])

              summary = {
                  'started_at': utc_now_iso(),
                  'dry_run': settings['dry_run'],
                  'retention_in_days': settings['retention_in_days'],
                  'only_if_no_retention': settings['only_if_no_retention'],
                  'excluded_regions': settings['excluded_regions'],
                  'regions': [],
                  'totals': {
                      'evaluated': 0,
                      'would_update': 0,
                      'updated': 0,
                      'skipped_existing_retention': 0,
                      'skipped_excluded_prefix': 0,
                      'access_denied': 0,
                      'failed': 0
                  },
                  'region_results': []
              }

              try:
                  regions = get_enabled_regions(settings['excluded_regions'])
                  summary['regions'] = regions
                  for region in regions:
                      try:
                          region_result = scan_region(region, settings)
                      except Exception as e:
                          region_result = {
                              'region': region,
                              'evaluated': 0,
                              'would_update': 0,
                              'updated': 0,
                              'skipped_existing_retention': 0,
                              'skipped_excluded_prefix': 0,
                              'access_denied': 0,
                              'failed': 1,
                              'failures': [{
                                  'error_code': type(e).__name__,
                                  'error_message': str(e),
                                  'trace': traceback.format_exc()
                              }],
                              'updates': []
                          }

                      summary['region_results'].append(region_result)
                      for key in summary['totals']:
                          summary['totals'][key] += int(region_result.get(key, 0) or 0)

              finally:
                  summary['ended_at'] = utc_now_iso()
                  print(json.dumps(summary, indent=2, default=str))

              return summary

  RemediatorScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for CloudWatch Log Retention Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: CloudWatchLogRetentionRemediatorTarget
          Input: '{"source":"eventbridge-schedule"}'

  RemediatorSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref RemediatorFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt RemediatorScheduleRule.Arn

Outputs:
  RemediatorFunctionName:
    Description: Lambda function name.
    Value: !Ref RemediatorFunction

  RemediatorFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt RemediatorFunction.Arn

  DryRunSetting:
    Description: Current DryRun setting.
    Value: !Ref DryRun

  RetentionInDaysSetting:
    Description: Current retention setting.
    Value: !Ref RetentionInDays

  ManualDryRunExample:
    Description: Example manual dry-run invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true}'' response.json'

  ManualApplyExample:
    Description: Example manual apply invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":false}'' response.json'
